Skip to content

fix(connect): enforce server-side OAuth nonce validation and replay protection #273

Open
Ridanshi wants to merge 1 commit into
Dev-Card:mainfrom
Ridanshi:fix/oauth-nonce-validation
Open

fix(connect): enforce server-side OAuth nonce validation and replay protection #273
Ridanshi wants to merge 1 commit into
Dev-Card:mainfrom
Ridanshi:fix/oauth-nonce-validation

Conversation

@Ridanshi
Copy link
Copy Markdown
Contributor

Problem

The GitHub platform-connect flow (/api/connect/github) generated a nonce embedded in the OAuth state parameter but never persisted or validated it server-side.

The callback flow only validated the structure of the decoded payload:

if (
  typeof decoded.userId !== 'string' ||
  typeof decoded.nonce !== 'string'
) {
  return null;
}

and then trusted the attacker-controlled userId from the state payload when storing OAuth credentials.

Because the nonce was never:

  • persisted,
  • cross-checked,
  • expired,
  • or invalidated,

an attacker could forge arbitrary OAuth state payloads and link their GitHub account to another user's DevCard profile.


Security Impact

This created a realistic OAuth CSRF/account-linking vulnerability.

An attacker could:

  1. obtain a victim's user ID,
  2. generate a forged OAuth state,
  3. complete GitHub OAuth using their own GitHub account,
  4. submit the forged state during callback handling.

The backend would then:

  • trust the forged userId,
  • exchange the OAuth code,
  • and persist the attacker's GitHub token under the victim's account.

This could result in:

  • unauthorized account linking,
  • incorrect OAuth identity association,
  • replay attacks,
  • and compromised social integration integrity.

Fix

apps/backend/src/routes/connect.ts

Implemented full server-side nonce persistence and validation.

OAuth Initiation

  • generates a cryptographically secure nonce using:
    randomBytes(32).toString('hex')
  • stores:
    oauth_nonce:{nonce} -> userId
    in Redis
  • applies a strict 600-second TTL
  • fails closed if Redis persistence fails

OAuth Callback

The callback flow now:

  1. safely parses and validates state structure
  2. looks up the nonce in Redis
  3. verifies nonce ownership matches the expected user
  4. deletes the nonce immediately after validation
  5. rejects:
    • forged nonces
    • expired nonces
    • replay attempts
    • mismatched user IDs
  6. uses the Redis-sourced user ID as authoritative instead of trusting attacker-controlled state payloads

Additionally:

  • Redis failures now fail closed
  • token exchange never occurs for invalid state
  • nonce consumption is single-use and replay-safe

Security Properties

The updated flow is now:

  • Replay-safe

    • nonces are consumed immediately after validation
  • Forgery-resistant

    • attackers cannot generate valid server-issued nonces
  • Fail-closed

    • Redis failures abort the OAuth flow safely
  • Server-authoritative

    • user association comes from Redis, not client-controlled state

Tests

Replaced the previous placeholder OAuth tests with comprehensive regression coverage in:

apps/backend/src/__tests__/connect.test.ts

Added coverage for:

  • valid OAuth flow
  • forged nonce attempts
  • replay attacks
  • mismatched user IDs
  • malformed base64 state
  • malformed JSON payloads
  • missing parameters
  • Redis persistence failures
  • Redis lookup failures
  • expired/consumed nonces
  • GitHub token exchange failures
  • nonce TTL validation

Result

  • OAuth state validation is now enforced server-side
  • Replay attacks are prevented
  • Forged account-linking attempts are rejected
  • Nonces are single-use and time-bound
  • Existing legitimate OAuth behavior remains unchanged

Fixes #223

@Harxhit
Copy link
Copy Markdown
Collaborator

Harxhit commented May 23, 2026

@Ridanshi Please fix the merge conflicts.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add test here?

userId,
'EX',
OAUTH_NONCE_TTL_SECONDS,
);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add logging please.

@Harxhit Harxhit added the gssoc:approved Required label for every approved PR. Gives the base +50 points and enables contribution tracking. label May 23, 2026
…t-linking

The GitHub connect flow generated a nonce in the OAuth state payload but
never stored or verified it server-side.  An attacker could craft an
arbitrary state value containing any userId and, by supplying a valid
GitHub authorization code, link their GitHub account to a victim's DevCard
account without the victim's consent.

Fix:
- During /github initiation: store `oauth_nonce:{nonce} -> userId` in Redis
  with a 600-second TTL before issuing the OAuth redirect.  If Redis is
  unavailable the redirect is aborted (fail closed) so an unvalidated flow
  can never be started.
- During /github/callback: look up the nonce in Redis, delete it immediately
  (single-use), then assert the stored userId matches the state payload.
  Any failure — unknown nonce, expired nonce, replay, userId mismatch, or
  Redis error — redirects to connect_failed without proceeding to token
  exchange.
- The authoritative userId for token storage is taken from Redis, not from
  the attacker-controlled state, so a tampered userId can never be trusted
  even if the nonce check somehow passed.

Tests added (connect.test.ts):
- Valid nonce flow: nonce persisted, consumed, token stored
- Redis unavailable on initiation: 500, no redirect issued
- Forged state (unknown nonce): rejected, fetch never called
- Replay attack: second request blocked after first consumes nonce
- userId mismatch: nonce valid but claimed for wrong user — rejected
- Malformed base64 state: safely rejected before Redis lookup
- Missing-field JSON payload: safely rejected before Redis lookup
- Missing code / missing state: rejected with missing_params
- Redis failure during lookup: fails closed with server_error
- GitHub rejects the code: connect_failed, no token stored
@Ridanshi Ridanshi force-pushed the fix/oauth-nonce-validation branch from 9c97314 to 58f0d23 Compare May 23, 2026 18:12
@Ridanshi
Copy link
Copy Markdown
Contributor Author

Addressed all requested review changes and updated the branch.

  • Resolved the merge conflicts in connect.ts while preserving the OAuth nonce persistence, replay-protection, and fail-closed validation flow.
  • Added structured security-safe logging for malformed state handling, replay attempts, Redis failures, nonce mismatches, and OAuth exchange failures without exposing sensitive data.
  • Expanded the regression coverage in connect.test.ts with additional validation and replay-protection scenarios.
  • Added encryption module mocking for stable isolated OAuth test execution.

Verification:

  • connect.test.ts → 14/14 tests passing
  • Rebased successfully on the latest upstream main
  • Existing legitimate OAuth behavior remains unchanged while forged/replayed state flows are now rejected deterministically

Branch has been updated and is ready for re-review.

@Harxhit
Copy link
Copy Markdown
Collaborator

Harxhit commented May 23, 2026

Addressed all requested review changes and updated the branch.

* Resolved the merge conflicts in `connect.ts` while preserving the OAuth nonce persistence, replay-protection, and fail-closed validation flow.

* Added structured security-safe logging for malformed state handling, replay attempts, Redis failures, nonce mismatches, and OAuth exchange failures without exposing sensitive data.

* Expanded the regression coverage in `connect.test.ts` with additional validation and replay-protection scenarios.

* Added encryption module mocking for stable isolated OAuth test execution.

Verification:

* `connect.test.ts` → 14/14 tests passing

* Rebased successfully on the latest upstream `main`

* Existing legitimate OAuth behavior remains unchanged while forged/replayed state flows are now rejected deterministically

Branch has been updated and is ready for re-review.

Please add terminal ss proof.

@Ridanshi
Copy link
Copy Markdown
Contributor Author

Screenshot 2026-05-24 001356

Added the requested terminal screenshot proof after re-running the affected OAuth regression tests.

Verified:

  • Invalid OAuth state rejection
  • Malformed OAuth payload handling
  • Callback failure handling

All related tests are passing successfully and the branch has been updated for re-review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gssoc:approved Required label for every approved PR. Gives the base +50 points and enables contribution tracking.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth CSRF — State Nonce Never Validated Server-Side

2 participants